Skip to content

Oklab color spaces - #335

Merged
smelfungus merged 25 commits into
masterfrom
feat/oklab-color-spaces
Sep 15, 2026
Merged

smelfungus merged 25 commits into
masterfrom
feat/oklab-color-spaces

Conversation

@smelfungus

Copy link
Copy Markdown
Member

No description provided.

@smelfungus smelfungus self-assigned this Sep 2, 2026
HSL lightness is not perceived lightness: blue and yellow at 0.5 differ by more than 0.3 in Oklab L, which is why an HSL lightness slider does something visibly different at every hue. Okhsl and Okhsv fix that, and their saturation is measured against the sRGB gamut, so full saturation is reachable at every hue instead of running off the end of what the screen can show. Oklab and OkLCh come along for interpolation and for moving values in and out of CSS.

This is a breaking change. PickerColor is a public sealed interface, so four new implementations stop any consumer's exhaustive `when` from compiling — hence 2.0.0 rather than 1.2.0. The saved-state format keeps its existing space keys, so a state persisted by 1.x still restores.

Out-of-gamut Oklab and OkLCh go through the CSS Color 4 algorithm rather than either obvious alternative. Clamping each RGB channel shifts lightness and hue as a side effect, which is what the LAB path already does. Reducing chroma alone holds both but over-corrects badly on yellows, where the gamut surface curves away from the search line. The spec's binary search with local-MINDE clipping keeps the chroma that pure reduction throws away.

Eight spaces would have meant sixty-four hand-written branches in the derived-state matrix, so conversions now route through RGB instead of being spelled out pairwise.
computeMaxSaturation declared eight uninitialized vals and filled them from a three-way if chain, which is how the C++ reference has to do it and not how Kotlin does. The coefficients are now three named constants picked by a when, so the branch says which channel clips first instead of assigning to eight names.

The two grays in each of toOkhsl and toOkhsv were built twice from the same arguments, once per guard. Hoisting the construction turns both guards into single lines.

The hue sliders built a whole gradient per coloring mode when the modes differ only in the saturation and lightness the strip is drawn at. Choosing that pair first also tightens the remember keys: Independent no longer rebuilds the track when the color's own saturation changes.
The eight derived views were the same four lines eight times over, differing only in the target type and the conversion — which is exactly `as? T ?: convert(...)` behind a reified parameter. Naming that collapses the block to one line per space, and the branch now reads as the type test it always was rather than an if.

The helper is private and inlined, so nothing moves in either API dump.
ExperimentalKotlinGradlePluginApi is no longer required to opt into the Android target's compilerOptions block, and OkConversionsTest names OklabColor nowhere — it only reaches the type through toOklab().
96095f2 took ExperimentalKotlinGradlePluginApi out of the library's build script and left the identical opt-in standing in the sample's, where the Android target's compilerOptions block no longer needs it either.
The comment justifying 16 claimed the saturation, lightness and value tracks stay within half a step of 255 of the true sweep. Measured against that sweep, a 16-stop strip drifts 12.97 of 255 on the saturation track and 10.75 on the lightness one: these tracks curve hardest exactly where the gamut does, near the colorful end. 64 holds the worst case under 3.5. The old figure looks carried over from HSL, whose tracks really are near-exact at two and three stops because HSL is piecewise linear in sRGB and Okhsl is not.

HUE_TRACK_SATURATION was read by the lightness and value tracks as well, whose KDoc meanwhile promised full saturation. It is now INDEPENDENT_TRACK_SATURATION, shared rather than declared once per file, and the two @PARAM lines say the 0.85 they actually draw.

The four Ok* reference images move with the stop count; the other ten are untouched.
OkhslColor() and OkhsvColor() were documented as opaque red, the first of them "matching HslColor()". They are #D70071 and #FF0088. Ok* hue is Oklab's hue angle, where sRGB red sits at 29.2 degrees, so anyone swapping HslColor() for OkhslColor() on the strength of that sentence gets a different color.

The share of sRGB falling outside the reference implementation's straight line to black measures 1.46%, not the 4% claimed. The companion figure in the same sentence — at most 2.6% of the boundary chroma — measures 2.62% and stands. Okhsv's round trip loses 4.3 steps of 255 rather than 4; Okhsl's 3.8 is as documented.

The gallery still said every picker but HslColorPicker defaults to Contextual, immediately above the table that now lists two more Independent ones.

Two silent conversions are now said out loud: toOklch clamps chroma to 0.4 where Oklab's independently bounded a and b reach 0.566 at the corners of their square, and the derived Oklab and OkLCh views route through sRGB like every other pair, so an out-of-gamut origin comes back gamut-mapped even though those two spaces describe the same color exactly.

computeMaxSaturation's KDoc sat above MaxSaturationFit with a second block after it, which left the function undocumented and the class described twice.
ColorModelTest pins validation, clamping, hue normalization and the int accessors for the four original models and nothing at all for the four new ones. OkColorModelTest does the same for Oklab, OkLCh, Okhsl and Okhsv, including the signed-zero normalization the equals contract depends on.

theSpaceKeysOfTheExistingSpacesAreUnchanged checked HSL, RGB and LAB but skipped CMYK — the one space that spends c4 on alpha instead of leaving it unused, so the case most likely to break was the one not covered. It now asserts the restored color as well as the key, since an unchanged key says nothing about the layout behind it.
docs/ is the Jekyll source for the project pages, so anything sitting under it is published by default. The notes under docs/superpowers are working material rather than documentation and are not tracked, but they are on disk, so a local build picks them up unless _config.yml excludes them.
Every input in the library was a slider, so picking two channels took two gestures and there was no surface to drag across. This is the S+L plane from the #49 roadmap.

The surface is a horizontal grey-to-hue ramp under a white/transparent/black overlay rather than a computed bitmap, and that pair is exact rather than approximate: HSL at lightness L is the mid-lightness colour blended toward white by 2L-1 above the middle and toward black by 1-2L below it, which is what compositing the overlay does. Zero error across 43911 sampled points, and a rendering test pins the four corners in a real composition.

It does not mirror in right-to-left layouts, where every slider does. Saturation would grow leftwards on the plane while still growing rightwards on the hue slider beside it.

Both channels go through a single updateFromHsl, so a drag cannot disturb hue or alpha.

A two-dimensional drag has no linear screen-reader equivalent; the surface carries a label and both values, and the sliders remain the accessible path to the same channels.
The indicator was the one part of the plane that mirrored. `Modifier.offset` places relatively, so in a right-to-left layout the ring sat over grey at saturation 0.9 while the gradient and the pointer mapping stayed where they were, and tapping the ring threw the value to the far side. `absoluteOffset` alone does not settle it: the wrapping `Box` aligns to `TopStart`, which mirrors too and pushes the indicator clean off the surface. Placing from a `Layout` with `place` handles both, and drops the `onSizeChanged` state the offset needed to know how big the plane was.

The `thumb` slot sat in a wrapper fixed at `PlaneThumbSize`, so a larger replacement was squeezed back to 24dp and a smaller one was stranded in that wrapper's corner, 8dp off the value it marks. The wrapper now takes its size from what it holds.

The shape clipped the whole plane, indicator included. At a corner the rounding left about an eighth of the ring — the position where a picker most has to show where the colour came from. It clips the surface alone now, so the ring overflows the edge the way a slider thumb overflows its track.

The golden shifts by a few pixels of anti-aliasing around the ring. The placement arithmetic is the same; the indicator reaches it as a measured child rather than through an offset modifier.
The surface was six lines of the file and the only part of it that knew about HSL: a horizontal ramp under a white-to-transparent-to-black overlay, which reproduces HSL exactly because at fixed hue and saturation the space is piecewise linear in lightness. Everything around it — the layout that clips the surface but not the indicator, the gesture handling, the unmirrored right-to-left behaviour — is about planes rather than about HSL, and the Ok* spaces need all of it and none of the paint.

ColorPlane takes the field as a DrawScope lambda and works in fractions, the way ColorSlider takes a value and a list of stops. HslPlane is what is left once the paint moves out.

The rename is free: the plane has never been in a release.
Okhsl and Okhsv are not linear in either axis, so the crossed gradients that reproduce HSL exactly cannot draw them: the sampled field has to be built and scaled instead.

Samples are endpoint-aligned rather than at texel centres. Clamping the outer half-texel would render the full-saturation edge at 0.992, which measures 23.7 of 255 at 64 columns and lands on exactly the colours a perceptual picker exists to reach.

The grids are not square. What is left after interpolation is a crease along the cusp lightness, where the gamut boundary turns a corner; it runs along the x axis, so rows cross it and columns do not. 64 by 256 measures 9.19 of 255 for Okhsl where a square 128 by 128 measures 15.57 at the same pixel count. Okhsv puts its cusp on the corner of the square and needs none of that, reaching 1.94 of 255 at 64 by 64.
theOkhslGridHoldsItsBudget swept 6 hues over a 61x61 point grid, and that was coarse enough to hide a doubling of the real error: forcing OKHSL_PLANE_ROWS down to 64, whose true worst error is the 23.47 of 255 that PlaneRaster.kt's own KDoc documents, the old sampling read back only 6.25 and passed. The error sits in a narrow needle right at the gamut cusp rather than spread across the surface, and a sweep that coarse steps straight over it.

worstError now samples 121x121 points per hue, and both budget tests sweep every 20 degrees instead of 60 — 18 hues instead of 6, the density behind the KDoc's 9.19 and 1.94 figures. Measured at that density the two grids land almost exactly on those numbers, with the 12.0 and 3.0 budgets and the grid constants unchanged; the headroom between the two is now real rather than an artifact of where the sweep happened to land.
The square is what Okhsl is for. A row of it looks equally light all the way across, which an HSL plane's never does, and the right edge is the most colourful the hue can reach on the display rather than a stretch of coordinates the screen cannot show.

The field is sampled at 64 by 256 and scaled. Both numbers are measured rather than picked: the residual is a crease where the gamut boundary turns its corner, rows cross it and columns do not.
Okhsv keeps the arrangement a colour picker is expected to have — vivid at the top right, black along the bottom — while making every point in the square reachable on the display, which an HSV square never manages at the top edge.

It needs a quarter of the rows the Okhsl plane does. Its cusp sits on the corner of the square rather than crossing the middle, so there is no crease for extra rows to resolve and 64 by 64 already measures under two of 255.
CIELAB travels between tools at D50 — CSS lab(), Photoshop and Compose's own ColorSpaces.CieLab all quote it there — while this converted at D65, so a value copied in from any of them landed a median ΔE76 of 2.7 off over a 17³ sweep of sRGB and 7.1 at the 95th percentile, against the 2.3 where a difference starts to show at all. sRGB red read 53.24, 80.09, 67.20 where all three of those say 54.29, 80.81, 69.89.

The matrices carry the adaptation folded in rather than applying it as a second step. Adapting separately with CSS Color 4's published Bradford matrix leaves it disagreeing in the last digits with whichever D50 the f() term divides by, and white stops landing on the neutral axis — it picks up a b* of 0.014.

Out-of-gamut colors were clipped channel by channel, which drags lightness and hue along with the chroma: Lab(50, 0, -128) rendered as #008AFF, which reads back as Lab(57.6, 12.3, -66.3) — 7.6 of lightness nobody asked for. That was most of what the picker could reach, since only about an eighth of the box is inside sRGB and over half of an a* or b* slider's travel at L* 50 is outside it. Those colors now go through gamutMapToSrgb, as the Oklab spaces already did, while in-gamut ones skip the Oklab round trip and keep the arithmetic they had.

What the search holds is Oklab's lightness and hue, not CIELAB's, since CSS runs it in Oklab whatever space the color came from. L* arrives within 3.2 rather than unchanged. The hue angle CIELAB itself reports can swing further than clipping moved it — the two spaces disagree most on CIELAB's non-uniform blue axis, where lab(50% 0 -128) reads 270° in CIELAB and 221° in Oklab, so holding the latter turns the former by 38° against clipping's 5.5. Oklab's angle is the one that tracks what the eye sees, and it holds to within 4° where clipping lost 35.

The tracks drew 11 stops, leaving the Independent a* strip 37.8 of 255 from the color the thumb above it was painted with — the same disagreement the hue gradient was fixed for, on a track that never got the same treatment. 64 stops, matching the Ok* tracks, takes that to 7.4 and is where the return goes flat: over half of a LAB track lies outside sRGB, where the mapped color runs along the gamut surface and turns a corner at every edge of the RGB cube it crosses, so the error stalls rather than falling. It is still 5.5 at 128 stops, and the Contextual tracks hold between 14 and 25 of 255 at any count. LabGradientTest pins those as budgets rather than as a bar the tracks clear.
isInGamut runs inside the gamut mapper's binary search, so the thing to check before spelling a bound as a range is whether the range allocates. It does not: over a primitive the compiler lowers `in` to the same pair of comparisons the code used to spell out.
worstError walked a fixed 121-point grid on each axis, and both axes let the peak slip between samples. On y, a row midpoint is where bilinear interpolation between two rasterized rows is least accurate, and a fixed grid can straddle one without ever landing near it. On hue, the error peaks in a needle about a degree wide — around 110 for Okhsl, where the sRGB channel that clips first changes, and 264 for Okhsv — which a uniform 20 degree step sails straight over.

Sampling every row midpoint as well, and sweeping the needle at 0.2 degrees, moves the Okhsl plane from 9.19 of 255 to 35.10 at the 256 rows it ships at, and the Okhsv plane from 1.94 to 2.28. Columns still buy nothing: at 256 rows, 64, 128 and 256 of them all land on the same 35.10. The budgets move to follow the measurement rather than the other way round.
HslPlane was the only plane when these were written, so ColorPickerDefaults, ColorPickerShapes, ColorPickerState and SliderInteractionGuard each name it where they mean any plane, and the README still describes one surface where there are three, on two different vertical axes.

buildPlaneBitmap claimed only the cheap tail of an Ok* conversion runs per pixel, with the cusp hoisted out per hue and the chroma anchors per row. Nothing hoists them: color is called once per pixel and runs the conversion whole, cusp finding and its Halley refinement included, even though hue is fixed for the whole bitmap and lightness repeats down every row. rememberPlaneBitmap keys on hue, so dragging a hue slider under an OkhslPlane rebuilds all 64 by 256 of them on every change.
@smelfungus
smelfungus force-pushed the feat/oklab-color-spaces branch from 96095f2 to 210e1bc Compare September 15, 2026 20:33
buildPlaneBitmap took one function of x and y and called it per pixel, which gave a caller nowhere to put work that does not vary per pixel. The Ok* planes paid for that: an Okhsl surface holds one hue across all 16384 of its pixels and one lightness across each of its 256 rows, yet every pixel re-found the cusp — a polynomial fit and a Halley step — along with the mid tint and the chroma anchors. It now takes a function of y returning the function for that row, so the hue level runs once and the lightness level once per row.

OkHue holds what an Ok* conversion settles from the hue alone, and getChromaAnchors becomes the one-shot form of it for callers converting a single color. okhslAtHue and okhsvAtHue are the same arithmetic as the toRgb above each of them, in the same order, so nothing is approximated — Okhsv lifts only the cusp, since its lightness and chroma fall out of saturation and value together.

The conversion itself is 2.2x faster, but a plane rebuild only drops from 8.05 ms to 6.21 ms on a desktop JVM, 2.13 to 1.76 for the smaller Okhsv grid. What is left is the rasterizer's own loop: filling the bitmap with one drawRect per pixel costs about 4.9 ms of that and is now four fifths of the total, so it is where the next cut has to come from.

PlaneFieldTest pins each field against the public conversion at every grid point across 36 hues, exactly rather than within a budget — hoisting moves arithmetic out of a loop without reordering any of it, so a tolerance would hide the one thing worth guarding. The screenshot references are unchanged, which is the same claim end to end.
Rasterizing drew one rectangle per pixel, so a 64 by 256 plane made 16384 calls into the toolkit to paint 16384 pixels. Every toolkit can take the whole buffer at once; none of them agree on how to ask, which is what imageBitmapFromPixels is for — an Android actual over Bitmap.createBitmap, available since API 1 so minSdk 24 is no obstacle, and one shared Skiko actual for JVM, iOS and Wasm. It is the library's first expect, and deliberately the smallest one that does the job: no colour maths crosses it, so Okhsl and Okhsv stay single-sourced in common code.

Measured on a plane rebuild, which is what a hue drag costs. A Pixel 6 Pro goes from 36.71 ms to 17.79 ms, Chrome from 29.94 to 11.80, and a desktop JVM from 6.79 to 2.43. Android was the slowest of the three before the change and still is after it, and 17.79 ms is over a frame at 60 Hz, so a hue drag above an OkhslPlane still drops frames on hardware that is not slow. What is left is the conversion itself, where the per-pixel OkLab, LinearRgb and RgbColor allocations are the obvious next thing to look at — a JVM elides them and Kotlin/Native and Wasm largely do not.

The packing rounds to nearest rather than truncating, which is what Skia did with the colour it was handed, so the fourteen screenshot references are unchanged. PlaneFieldTest pins the pixels against the field that produced them to within half a step of 255, the most an 8-bit channel can carry.

skikoMain hangs off each of jvmMain, wasmJsMain and the two iOS mains by name. Routing it through iosMain instead leaves the iOS compilations seeing only commonMain, and the actual then goes missing at link time rather than at configuration, where it would be easy to read.
Both ended with two newlines rather than one.
A plane rebuild cost 17.76 ms on a Pixel 6 Pro, and a hue drag rebuilds on every frame, so the one gesture that changes hue above a plane ran at about half a frame's budget. Measured layer by layer on the device, the arithmetic was the small part: the per-pixel objects and the call per pixel came to 9.6 ms, delinearize's pow to 5.1 ms, the arithmetic itself to 2.9, and handing the finished array to the toolkit to 0.14.

None of that shows on a JVM, which is why it had gone unnoticed. Escape analysis removes the objects outright and its pow is thirty times cheaper, so the same breakdown there reads 0.19 ms for pow and nothing at all for the objects. Profiling this on desktop points at the wrong half of the problem.

So buildPlaneBitmap takes a row filler rather than a function returning a colour per pixel, and okhslRowFiller and okhsvRowFiller run flat loops over scalars, packing each pixel as they go. oklabToLinearSrgb gained an inline form that hands its three components to a lambda instead of a LinearRgb, so the matrix is still written once and the loop still allocates nothing.

linearToSrgbByte replaces the pow. Byte b covers the linear values from linearize((b + 0.5) / 255) upward, and linearize is delinearize's inverse, so those 255 boundaries are exact rather than a sampling of the curve — locating a value between them gives the byte rounding delinearize would have produced, everywhere, which PlaneFieldTest checks over 200001 samples.

That is 3.94 ms on the Pixel, 3.26 in Chrome and 1.45 on a desktop JVM. Against the 36.71 ms the same device measured before any of this, 9.3x.

The Okhsl plane's reference image moves by a step of 255 on a handful of pixels. Encoding now rounds the double the conversion computed, where before it rounded a float that had already lost precision on its way through RgbColor, and the two differ only where that lost precision straddled a byte boundary. PlaneFieldTest holds the filler to within one step of the public conversion for that reason, and to the exact encoding separately.
Mocha stops a browser test at two seconds by default, and three of the plane tests sweep millions of conversions to pin a numeric budget. On Wasm they run past it and fail as timeouts rather than as anything about colour, which is why allTests went red on CI while jvmTest stayed green.

The sampling is what the budgets rest on. The grids were deliberately made dense because the error peaks in a needle about a degree wide that a coarser sweep steps straight over and reports a fraction of, so thinning them to fit two seconds would leave the assertions measuring nothing. The limit moves instead.

The whole wasmJsBrowserTest task takes about 23 seconds including compilation, so the ceiling is there to catch a test that has genuinely hung rather than to bound these.
@smelfungus
smelfungus merged commit f4e42c8 into master Sep 15, 2026
2 checks passed
@smelfungus
smelfungus deleted the feat/oklab-color-spaces branch September 15, 2026 22:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant